10.1 头文件¶
简介¶
通常每一个.cpp文件都有一个对应的.h文件,不过也有一些例外,如单元测试代码和只包含main()函数的.cpp文件。
重定义问题¶
1. 问题描述¶
头文件中存放的一般是关于类、变量和函数的声明。当在头文件中包含全局变量或者非内联函数的定义时就可能出现重定义问题,C++中重定义错误包含如下两种情况:
情况一:多个源文件包含了同个头文件,且头文件中包含了某个全局变量或者非内联函数的定义(链接期间报错)
情况二:某个源文件多次包含同一个头文件(编译期间报错)
2. 解决方法¶
对于情况一而言,我们应当尽量避免在头文件中定义全局变量或者非内联函数,全局变量可以在头文件中用extern声明然后在源文件中定义,函数定义可以加上inline关键字变成内联函数。由于编译器会将类、内联函数以及const变量默认视为定义它们的源文件所私有,因此这些类型可以定义在头文件中。
对于情况二而言,我们可以在头文件中添加头文件保护符:
// 法一
#ifndef FOO_H
#define FOO_H
/* foo.h的内容 */
#endif
// 法二: Google编码规范不推荐
#pragma once
声明与定义分离¶
C++支持分离式编译机制,该机制允许将程序分割为若干个文件,每个文件可独立编译。
为了支持分离式编译,C++将定义和声明区分开。其中声明规定了变量的类型和名字,定义除此功能外还会申请存储空间并可能为变量赋一个初始值。
1. 声明变量¶
如果想声明一个变量而非定义它,就使用关键字extern并且不要显式地初始化变量:
变量能且仅能被定义一次,但是可以被多次声明。
extern int i; // 声明i而非定义i
extern int i = 1; // 定义i, 这样做抵消了extern的作用
2. 声明函数¶
对于函数而言,声明和定义本来就是有区别的(定义函数要有函数体,声明函数不需要函数体),因此声明函数时extern关键字是可有可无的(或者函数声明本身就是extern的)。
假设我们在foo.h中声明了foo_func()函数,然后在foo.cpp中定义它,当我们想要在bar.cpp中引用该函数时,使用extern声明和直接#include foo.h还是有一些区别的。
假设我们有foo.h和foo.cpp文件,并且在其中定义了foo()等函数:
/*
* foo.h
*/
#ifndef FOO_H
#define FOO_H
void foo();
#endif
/*
* foo.cpp
*/
#include <iostream>
void foo() {
std::cout << "foo()" << std::endl;
}
2.1 使用#include引入函数声明¶
/*
* main.cpp
*/
#include "foo.h"
int main() {
foo();
return 0;
}
2.2 使用extern引入函数声明¶
在前面的例子中,我们在mian.cpp中#include "foo.h"从而可以使用声明在foo.h中的函数。但是这样的做法会将foo.h中所有的声明都引入到main.cpp中,假设我们只需要foo()函数就显得很没有必要。我们可以通过在函数声明前加上extern表明这个函数是定义在其他.cpp文件中的,这样一方面可以使得代码更佳清晰简洁,另一方面也会加快编译期间预处理的速度。
extern void foo();
int main() {
foo();
return 0;
}
头文件存放的内容¶
1. 类定义和函数声明¶
通常当我们调用一个函数时,编译器只需要掌握函数的声明;类似地,当我们使用一个类类型的对象时,类定义必须是可用的,但是成员函数的定义不必已经出现。因此我们通常将类定义和函数声明放在头文件中,而普通函数和成员函数的定义放在源文件中。
2. 函数模板或者类模板成员函数的定义¶
2.1 模板头文件包含声明与定义¶
Tips:一个头文件所需要的所有模板声明通常一起放置在文件开始位置,出现于任何使用这些模板的代码之前。
当编译器遇到一个模板定义时它并不生成代码,只有当我们实例出模板的一个特定版本时,编译器才会生成代码。即当我们使用而非定义模板时,编译器才生成代码。
为了生成一个实例化版本,编译器必须掌握函数模板或者类模板成员函数的定义,因此与非模板文件不同,模板的头文件通常既包含声明也包含定义。
2.2 定义函数模板时声明所有重载的函数版本¶
Tips:在定义任何函数模板之前,记得声明所有重载的函数版本,这样就不必担心编译器由于未遇到你希望调用的函数而实例化一个并非你需要的版本。
如果使用了一个忘记声明的非模板函数,那么代码将编译失败。但是对于重载函数模板的函数而言,如果编译器可以从模板实例化出与调用匹配的版本,则缺少的声明不会报错。
以下面的场景为例,如果缺少接受T*的模板版本,那么编译器会默认实例化接受const T&的模板版本,这可能与程序员的本意不符。
// 考虑如下调用方式
foo("bar");
// 有三个重载的foo版本
foo(const T&); // T被绑定到char[4]
foo(T*); // T被绑定到const char
foo(const string&); // 要求从const char*到string的类型转化
// 结论: 两个模板提供精确匹配, 所以都是可能被调用的函数, 由于第二个模板T*更加特例化, 因此编译器会选择第二个函数
3. 内联函数和constexpr函数¶
和其他函数不同,内联函数和constexpr函数可以在程序中多次定义。毕竟编译器想要展开函数仅有函数声明时不够的,还需要函数的定义。由于对于某个给定的内联函数或者constexpr函数来说,它的多个定义必须完全一致,基于这个原因内联函数和constexpr函数通常定义在头文件中。
4. 类的内联函数¶
和我们在头文件中定义inline函数的原因一样,inline成员函数也应该与相应的类定义在同一个头文件中。
头文件不该存放的内容¶
1. 不要包含命名空间的using声明或指示¶
头文件如果在其顶层作用域中含有using指示或using声明,则会将名字注入到所有包含该头文件的文件中。通常情况下,头文件应该只负责定义接口部分的名字,而不定义实现部分的名字。因此头文件最多只能在它的函数或命名空间中使用using指示或using声明。
2. 不要放全局变量和非内联函数的定义¶
如果在头文件中定义全局变量或者非内联函数,那么当这个头文件被多个源文件引用时,在链接阶段会出现重定义问题。
使用C++版本的C标准库头文件¶
Tips:C++程序员如果需要使用C语言标准库,应该使用C++重命名后的从属于命名空间std的头文件。
C++标准库除了定义C++特有的功能外,也兼容了C语言的标准库,但是将C标准库的头文件去掉了.h后缀,加上了c前缀。比如C标准库的头文件ctype.h被C++重命名为cctype。尽管cctype和ctype.h头文件的内容基本一样,但是cctype头文件中定义的名字从属于命名空间std。